package top.ddltech.stubborn.graphics3D.texture;

import top.ddltech.stubborn.math3D.PointLight3D;
import top.ddltech.stubborn.math3D.Rectangle3D;
import top.ddltech.stubborn.math3D.TexturedPolygon3D;
import top.ddltech.stubborn.math3D.Vector3D;

import java.lang.ref.SoftReference;
import java.util.ArrayList;

/**
 * A ShadedSurface is a pre-shaded Texture that maps onto a
 * polygon.
 */
public final class ShadedSurface extends Texture {

    public static final int SURFACE_BORDER_SIZE = 1;

    public static final int SHADE_RES_BITS = 4;
    public static final int SHADE_RES = 1 << SHADE_RES_BITS;
    public static final int SHADE_RES_MASK = SHADE_RES - 1;
    public static final int SHADE_RES_SQ = SHADE_RES * SHADE_RES;
    public static final int SHADE_RES_SQ_BITS = SHADE_RES_BITS * 2;

    private short[] buffer;
    private SoftReference<short[]> bufferReference;
    private boolean dirty;
    private ShadedTexture sourceTexture;
    private Rectangle3D sourceTextureBounds;
    private Rectangle3D surfaceBounds;
    private byte[] shadeMap;
    private int shadeMapWidth;
    private int shadeMapHeight;

    // for incrementally calculating shade values
    private int shadeValue;
    private int shadeValueInc;

    /**
     * Creates a ShadedSurface with the specified width and
     * height.
     */
    public ShadedSurface(int width, int height) {
        this(null, width, height);
    }

    /**
     * Creates a ShadedSurface with the specified buffer,
     * width and height.
     */
    public ShadedSurface(short[] buffer, int width, int height) {
        super(width, height);
        this.buffer = buffer;
        bufferReference = new SoftReference<>(buffer);
        sourceTextureBounds = new Rectangle3D();
        dirty = true;
    }

    /**
     * Creates a ShadedSurface for the specified polygon. The
     * shade map is created from the specified list of point
     * lights and ambient light intensity.
     */
    public static void createShadedSurface(
            TexturedPolygon3D poly, ShadedTexture texture,
            ArrayList<PointLight3D> lights, float ambientLightIntensity) {
        // create the texture bounds
        Vector3D origin = poly.getVertex(0);
        Vector3D dv = new Vector3D(poly.getVertex(1));
        dv.subtract(origin);
        Vector3D du = new Vector3D();
        du.setToCrossProduct(poly.getNormal(), dv);
        Rectangle3D bounds = new Rectangle3D(origin, du, dv,
                texture.getWidth(), texture.getHeight());

        createShadedSurface(poly, texture, bounds,
                lights, ambientLightIntensity);
    }

    public static void createShadedSurface(
            TexturedPolygon3D poly, ShadedTexture texture,
            Rectangle3D textureBounds,
            ArrayList<PointLight3D> lights, float ambientLightIntensity) {
        // create the surface bounds
        poly.setTexture(texture, textureBounds);
        Rectangle3D surfaceBounds = poly.calcBoundingRectangle();

        // give the surfaceBounds a border to correct for
        // slight errors when texture mapping
        Vector3D du = new Vector3D(surfaceBounds.getDirectionU());
        Vector3D dv = new Vector3D(surfaceBounds.getDirectionV());
        du.multiply(SURFACE_BORDER_SIZE);
        dv.multiply(SURFACE_BORDER_SIZE);
        surfaceBounds.getOrigin().subtract(du);
        surfaceBounds.getOrigin().subtract(dv);
        int width = (int) Math.ceil(surfaceBounds.getWidth() +
                SURFACE_BORDER_SIZE * 2);
        int height = (int) Math.ceil(surfaceBounds.getHeight() +
                SURFACE_BORDER_SIZE * 2);
        surfaceBounds.setWidth(width);
        surfaceBounds.setHeight(height);

        // create the shaded surface texture
        ShadedSurface surface = new ShadedSurface(width, height);
        surface.setTexture(texture, textureBounds);
        surface.setSurfaceBounds(surfaceBounds);

        // create the surface's shade map
        surface.buildShadeMap(lights, ambientLightIntensity);

        // set the polygon's surface
        poly.setTexture(surface, surfaceBounds);
    }

    /**
     * Gets the 16-bit color of the pixel at location (x, y) in
     * the bitmap. The x and y values are assumed to be within
     * the bounds of the surface; otherwise an
     * ArrayIndexOutOfBoundsException occurs.
     */
    @Override
    public short getColor(int x, int y) {
        return buffer[x + y * width];
    }

    /**
     * Gets the 16-bit color of the pixel at location (x, y) in
     * the bitmap. The x and y values are checked to be within
     * the bounds of the surface, and if not, the pixel on the
     * edge of the texture is returned.
     */
    public short getColorChecked(int x, int y) {
        if (x < 0) {
            x = 0;
        } else if (x >= width) {
            x = width - 1;
        }
        if (y < 0) {
            y = 0;
        } else if (y >= height) {
            y = height - 1;
        }
        return getColor(x, y);
    }

    /**
     * Marks whether this surface is dirty. Surfaces marked as
     * dirty may be cleared externally.
     */
    public void setDirty(boolean dirty) {
        this.dirty = dirty;
    }

    /**
     * Checks whether this surface is dirty. Surfaces marked as
     * dirty may be cleared externally.
     */
    public boolean isDirty() {
        return dirty;
    }

    /**
     * Creates a new surface and add a SoftReference to it.
     */
    protected void newSurface(int width, int height) {
        buffer = new short[width * height];
        bufferReference = new SoftReference<>(buffer);
    }

    /**
     * Clears this surface, allowing the garbage collector to
     * remove it from memory if needed.
     */
    public void clearSurface() {
        buffer = null;
    }

    /**
     * Checks if the surface has been cleared.
     */
    public boolean isCleared() {
        return (buffer == null);
    }

    /**
     * If the buffer has been previously built and cleared but
     * not yet removed from memory by the garbage collector,
     * then this method attempts to retrieve it. Returns true if
     * successful.
     */
    public boolean retrieveSurface() {
        if (buffer == null) {
            buffer = bufferReference.get();
        }
        return !(buffer == null);
    }

    /**
     * Sets the source texture for this ShadedSurface.
     */
    public void setTexture(ShadedTexture texture) {
        this.sourceTexture = texture;
        sourceTextureBounds.setWidth(texture.getWidth());
        sourceTextureBounds.setHeight(texture.getHeight());
    }

    /**
     * Sets the source texture and source bounds for this
     * ShadedSurface.
     */
    public void setTexture(ShadedTexture texture,
                           Rectangle3D bounds) {
        setTexture(texture);
        sourceTextureBounds.setTo(bounds);
    }

    /**
     * Sets the surface bounds for this ShadedSurface.
     */
    public void setSurfaceBounds(Rectangle3D surfaceBounds) {
        this.surfaceBounds = surfaceBounds;
    }

    /**
     * Gets the surface bounds for this ShadedSurface.
     */
    public Rectangle3D getSurfaceBounds() {
        return surfaceBounds;
    }

    /**
     * Builds the surface. First, this method calls
     * retrieveSurface() to see if the surface needs to be
     * rebuilt. If not, the surface is built by tiling the
     * source texture and apply the shade map.
     */
    public void buildSurface() {
        if (retrieveSurface()) {
            return;
        }

        int width = (int) surfaceBounds.getWidth();
        int height = (int) surfaceBounds.getHeight();

        // create a new surface (buffer)
        newSurface(width, height);

        // build the surface
        // assume surface bounds and texture bounds are aligned
        // (possibly with different origins)
        Vector3D origin = sourceTextureBounds.getOrigin();
        Vector3D directionU = sourceTextureBounds.getDirectionU();
        Vector3D directionV = sourceTextureBounds.getDirectionV();

        Vector3D d = new Vector3D(surfaceBounds.getOrigin());
        d.subtract(origin);
        int startU = (int) ((d.getDotProduct(directionU) -
                SURFACE_BORDER_SIZE));
        int startV = (int) ((d.getDotProduct(directionV) -
                SURFACE_BORDER_SIZE));
        int offset = 0;
        int shadeMapOffsetU = SHADE_RES - SURFACE_BORDER_SIZE -
                startU;
        int shadeMapOffsetV = SHADE_RES - SURFACE_BORDER_SIZE -
                startV;

        for (int v = startV; v < startV + height; v++) {
            sourceTexture.setCurrRow(v);
            int u = startU;
            int amount = SURFACE_BORDER_SIZE;
            while (u < startU + width) {
                getInterpolatedShade(u + shadeMapOffsetU,
                        v + shadeMapOffsetV);

                // keep drawing until we need to recalculate
                // the interpolated shade (every SHADE_RES pixels)
                int endU = Math.min(startU + width, u + amount);
                while (u < endU) {
                    buffer[offset++] =
                            sourceTexture.getColorCurrRow(u,
                                    shadeValue >> SHADE_RES_SQ_BITS);
                    shadeValue += shadeValueInc;
                    u++;
                }
                amount = SHADE_RES;
            }
        }
    }

    /**
     * Gets the shade (from the shade map) for the specified
     * (u, v) location. The u and v values should be
     * left-shifted by SHADE_RES_BITS, and the extra bits are
     * used to interpolate between values. For an interpolation
     * example, a location halfway between shade values 1 and 3
     * would return 2.
     */
    public int getInterpolatedShade(int u, int v) {
        int fracU = u & SHADE_RES_MASK;
        int fracV = u & SHADE_RES_MASK;

        int offset = (u >> SHADE_RES_BITS) +
                ((v >> SHADE_RES_BITS) * shadeMapWidth);

        int shade00 = (SHADE_RES - fracV) * shadeMap[offset];
        int shade01 = fracV * shadeMap[offset + shadeMapWidth];
        int shade10 = (SHADE_RES - fracV) * shadeMap[offset + 1];
        int shade11 = fracV * shadeMap[offset + shadeMapWidth + 1];

        shadeValue = SHADE_RES_SQ / 2 +
                (SHADE_RES - fracU) * shade00 +
                (SHADE_RES - fracU) * shade01 +
                fracU * shade10 +
                fracU * shade11;

        // the value to increment as u increments
        shadeValueInc = -shade00 - shade01 + shade10 + shade11;

        return shadeValue >> SHADE_RES_SQ_BITS;
    }

    /**
     * Gets the shade (from the built shade map) for the
     * specified (u, v) location.
     */
    public int getShade(int u, int v) {
        return shadeMap[u + v * shadeMapWidth];
    }

    /**
     * Builds the shade map for this surface from the specified
     * list of point lights and the ambient light intensity.
     */
    public void buildShadeMap(ArrayList<PointLight3D> pointLights,
                              float ambientLightIntensity) {
        Vector3D surfaceNormal = surfaceBounds.getNormal();

        int polyWidth = (int) surfaceBounds.getWidth() -
                SURFACE_BORDER_SIZE * 2;
        int polyHeight = (int) surfaceBounds.getHeight() -
                SURFACE_BORDER_SIZE * 2;
        // assume SURFACE_BORDER_SIZE is <= SHADE_RES
        shadeMapWidth = polyWidth / SHADE_RES + 4;
        shadeMapHeight = polyHeight / SHADE_RES + 4;
        shadeMap = new byte[shadeMapWidth * shadeMapHeight];

        // calculate the shade map origin
        Vector3D origin = new Vector3D(surfaceBounds.getOrigin());
        Vector3D du = new Vector3D(surfaceBounds.getDirectionU());
        Vector3D dv = new Vector3D(surfaceBounds.getDirectionV());
        du.multiply(SHADE_RES - SURFACE_BORDER_SIZE);
        dv.multiply(SHADE_RES - SURFACE_BORDER_SIZE);
        origin.subtract(du);
        origin.subtract(dv);

        // calculate the shade for each sample point
        Vector3D point = new Vector3D();
        du.setTo(surfaceBounds.getDirectionU());
        dv.setTo(surfaceBounds.getDirectionV());
        du.multiply(SHADE_RES);
        dv.multiply(SHADE_RES);
        for (int v = 0; v < shadeMapHeight; v++) {
            point.setTo(origin);
            for (int u = 0; u < shadeMapWidth; u++) {
                shadeMap[u + v * shadeMapWidth] =
                        calcShade(surfaceNormal, point,
                                pointLights, ambientLightIntensity);
                point.add(du);
            }
            origin.add(dv);
        }
    }

    /**
     * Determine the shade of a point on the polygon.
     * This computes the Lambert's reflection for a point on
     * the plane. Each point light has an intensity and a
     * distance falloff value, but no specular reflection or
     * shadows from other polygons are computed. The value
     * returned is from 0 to ShadedTexture.MAX_LEVEL.
     */
    protected byte calcShade(Vector3D normal, Vector3D point,
                             ArrayList<PointLight3D> pointLights, float ambientLightIntensity) {
        float intensity = 0;
        Vector3D directionToLight = new Vector3D();

        for (PointLight3D light : pointLights) {
            directionToLight.setTo(light);
            directionToLight.subtract(point);

            float distance = directionToLight.length();
            directionToLight.normalize();
            float lightIntensity = light.getIntensity(distance)
                    * directionToLight.getDotProduct(normal);
            lightIntensity = Math.min(lightIntensity, 1);
            lightIntensity = Math.max(lightIntensity, 0);
            intensity += lightIntensity;
        }

        intensity = Math.min(intensity, 1);
        intensity = Math.max(intensity, 0);

        intensity += ambientLightIntensity;

        intensity = Math.min(intensity, 1);
        intensity = Math.max(intensity, 0);
        int level = Math.round(intensity * ShadedTexture.MAX_LEVEL);
        return (byte) level;
    }
}
